
昨天談的是一個呼叫進來之後,框架怎麼知道要建哪一個型別。再往前推一格的問題是:這個呼叫本身長什麼樣子。一套上千張表單的系統對外要開出的方法數以千計,而其中絕大多數是同一組動作套在不同的表單上。協定要決定的是它們怎麼定址、參數怎麼帶、失敗怎麼回報。
框架的答案是一個 POST 端點、一個 method 字串、一條固定順序的管線。這三個決定各自有代價,而管線上有兩個位置是刻意排的。
本篇說明:
這是選擇不是結論。REST 與 RPC 兩邊都有跑了十幾年的系統,本篇不打算判高下,只說明這個框架的機制需求把它推向哪一邊。
動作的數量不跟著表單數走,資源的數量跟著。FormBusinessObject 上表單那一軸的 action 是六個,全部立成常數:
| 常數 | 用途 |
|---|---|
GetList |
清單查詢 |
GetLookup |
跨表關連的候選列查詢 |
GetNewData |
取空白骨架 |
GetData |
依列識別載入單筆 |
Save |
依每列狀態寫入 |
Delete |
依列識別刪除 |
八張表單是這六個,上千張表單還是這六個。變動的只有 ProgId 那一半,而 ProgId 昨天已經是型別註冊表的鍵。一個 method 就是這兩段接起來,多一張表單不必再開任何路由。
有一批動作不是資源的狀態轉換。昨天那個保留字 System 底下的 action 就不是 CRUD 的形狀,Login、Logout、EnterCompany、LeaveCompany、Ping、GetDefine 全在裡面。要把「進入某一家公司」對映成某個資源的建立或更新,得先發明一個資源出來。發明得出來,但那個資源是為了套進協定而存在的,不是系統裡本來就有的東西。
整個 body 是一份 JSON-RPC payload。請求的 params 與回應的 result 各自是一個欄位,裡面裝什麼、用什麼格式編碼,與方法本身無關。這是「傳輸格式可以整包換掉、而每一支方法的簽章都不必知道」的前提(留給 Day 22)。REST 把語意攤在路徑、動詞、狀態碼與 body 四個地方,能換掉的只有最後一個。
代價有兩筆:
POST /api,光看清單分不出誰是誰。框架的補救是在回應裡把 method 送回去,而規格裡沒有這個欄位(第三節談)。判別法不是哪個協定比較好,是這個系統對外的單位是資源還是程序。以表單為單位、以一組固定動作為介面的系統,答案偏向 RPC;以文件或實體為單位、且要吃到 HTTP 中介設施好處的系統,答案偏向 REST。
協定選定之後,實際要決定的是那份 payload 裡放什麼。它分兩層:外面那一層照 JSON-RPC 2.0 的規格走,裡面那一層是這個框架自己加的。
外面那一層四個欄位:
| 欄位 | 型別 | 內容 |
|---|---|---|
jsonrpc |
字串 | 固定 2.0 |
method |
字串 | ProgId.action |
params |
物件 | 一份 API payload,裝著這次呼叫的參數 |
id |
字串 | 呼叫端自選,回應原樣送回 |
params 這一格與規格的距離最遠。它允許兩種形狀:陣列代表位置參數,物件代表具名參數。框架兩種都不是,它固定是一份 API payload:
| 欄位 | 型別 | 內容 |
|---|---|---|
format |
整數 | value 用哪一種形式編碼,0 是不編碼 |
value |
任意 | 參數本體,呼叫端送出的 Request 物件,伺服端轉成方法宣告的 Args |
type |
字串 | value 的型別名,要不要填由 format 決定 |
編碼那一半留給 Day 22,本篇只用到一件事:format 與 type 在 API payload 上,不在 value 裡面。
這不是繞過規格,是因為「具名還是位置」這個選擇在這裡不存在。每一支對外方法都只收一個參數:
public virtual PingResult Ping(PingArgs args)
public virtual GetListResult GetList(GetListArgs args)
public virtual SaveResult Save(SaveArgs args)
派發那一段以單一元素的引數陣列呼叫,所以「一個方法一個參數物件」不是慣例而是硬性要求。換到的是新增一個輸入欄位只要在 Args 型別上加一個屬性。簽章不動,既有覆寫不必重編,這是編譯期那一側。
執行期還換到一件事:一套 ERP 服役期間這種欄位會一路長出來,而還沒升級的呼叫端仍然叫得動這支方法,那個新屬性在它送來的請求裡就是宣告上的預設值(傳輸格式怎麼撐住這件事,是 Day 22 的題目)。代價是連只需要一個字串的方法也得先有一個型別。
把上面那兩層填上實際的值,就是一次請求送出去的樣子。format 給 0,所以 value 是一棵讀得懂的 JSON 樹:
{
"jsonrpc": "2.0",
"method": "System.Ping",
"params": {
"format": 0,
"value": { "clientName": "curl", "traceId": "t-001" },
"type": ""
},
"id": "abc"
}
成功的回應:
{
"jsonrpc": "2.0",
"method": "System.Ping",
"result": {
"format": 0,
"value": { "status": "ok", "serverTime": "…", "traceId": "t-001" },
"type": ""
},
"id": "abc"
}
result 與 params 是同一種 API payload,value 裡才是這支方法自己的回傳內容。兩個方向各有一份屬性清單,寫在昨天那三層裡最底下的合約介面上(節選自 IPingRequest 與 IPingResponse):
public interface IPingRequest
{
string? ClientName { get; }
string? TraceId { get; }
}
public interface IPingResponse
{
string Status { get; }
DateTime ServerTime { get; }
ApiKeyStatus ApiKeyStatus { get; }
string? Version { get; }
string? TraceId { get; }
}
欄位名就是屬性名,中間沒有一層對映設定:請求的 value 兩個欄位對上 IPingRequest,回應的 value 對上 IPingResponse,上面那段回應只節錄了它五個屬性裡的三個。traceId 在兩份清單裡都有,它是請求送進去的值原樣回來。伺服端的 PingArgs / PingResult 與呼叫端的 PingRequest / PingResponse 各自實作這兩個介面,所以 value 那一格在兩端讀的是同一份清單。
比對規格,框架有四處不一致:
| JSON-RPC 2.0 規格 | 框架 | 照規格寫的 client 會遇到 |
|---|---|---|
| request 可以是陣列,代表一次批次 | 只收單一物件 | 送批次在反序列化就失敗 |
沒有 id 的請求是 notification,伺服端不回應 |
一律回應;id 缺席時回應裡就沒有這個欄位 |
沒有「送出去不等回覆」這條路 |
id 可以是字串、數字或 null |
宣告為字串 | 送數字的請求在反序列化就失敗 |
response 只有 jsonrpc / result / error / id |
多回一個 method |
多出一個規格沒有的欄位 |
前三件是規格有而框架沒有跟上,第四件是規格沒有而框架自己加的。協定在這裡的角色因此是格式的共同語言,不是相容性的保證。拿一份通用的 JSON-RPC client 函式庫直接接上去,會在批次與 id 型別這兩處碰壁。框架自己的 client 碰不到,因為兩端的 JSON-RPC payload 是同一份程式碼組出來的(那一側留給明天)。
method 是一個字串。要把它變成「哪一個型別的哪一個方法」,得先決定字串怎麼切、兩段各自去哪裡查。切法決定了命名空間有多大,查法決定了打錯字的症狀長什麼樣。
切的部分出自 JsonRpcExecutor,就這幾行:
private static readonly char[] s_methodSeparators = new[] { '.' };
var parts = method.Split(s_methodSeparators, 2);
if (parts.Length == 2) { return (parts[0], parts[1]); }
throw new FormatException($"Invalid method format: {method}");
三件事寫在這裡:分隔符只有句點一種;上限是兩段,第二個句點之後的東西整段留在 action 裡;切不出兩段就是格式錯誤,沒有「省略 ProgId 走預設」這種寬容。
method 就是 ProgId 加 action。昨天那份註冊表只有一層,所以 method 只需要這兩段,命名空間就是平的;代價是 ProgId 裡不能有句點。
| method | ProgId | action |
|---|---|---|
✅ System.Login |
System |
Login |
✅ Order.GetList |
Order |
GetList |
❌ System.Ping.Extra |
System |
Ping.Extra |
System.Ping.Extra 那一列是上限兩段的直接後果:多打的那一段不會被當成錯誤擋下來,它會變成一個找不到的方法名。
切完之後兩段走不同的查法。而在這之前,還有一個地方拿整串 method 去比對,所以同一個字串在一次請求裡被三個機制讀過,而它們的大小寫規則不一樣:
| 讀它的地方 | 比對什麼 | 大小寫 |
|---|---|---|
| 傳輸層的免憑證清單 | 完整的 method 字串 | 區分 |
| 型別註冊表 | ProgId | 不分 |
| 反射 | action | 區分 |
三者各自都合理,湊在一起就給出誤導的症狀。實測一支不需要登入的方法:System.Ping 正常回應,system.Ping 只差一個字母,回來的是 HTTP 401。那個字串掉出了免憑證清單,在傳輸層就被擋下來,根本沒走到後面兩個查法。成因是大小寫,症狀說的是憑證。
這不是設計出來的規則,是三個機制各自的預設值湊出來的,沒有一處把它們統一,也沒有任何一道檢查會指出來。框架能做的是把對外的方法名稱立成常數(第一節那張表裡的六個就是),但那只擋得住自己這一側,手寫請求的前端拼的還是字串。
action 不進註冊表也是刻意的。新增一支對外方法,只要在 BO 上宣告成公開的,不必登記;代價是名字打錯要等到第一次被呼叫才知道。這和昨天回傳型別那條命名慣例是同一筆交換:用慣例換到零登記,把錯誤延到執行期。差別是昨天那條還有形狀可以比對,方法名字沒有。
請求進來之後的每一步都是固定的,沒有任何一支方法能插隊或跳站。整條管線橫跨兩個組件,前兩站在 HTTP 那一層,其餘在派發那一層。
HTTP 這一層(框架的 controller 基底)
1 Content-Type 檢查 → 讀 body → 反序列化成請求物件 → method 不得為空
2 依 method 判定要不要憑證 → 檢查憑證 → 解出存取令牌
派發這一層
3 method 切成 ProgId 與 action
4 ProgId → BO 實例(型別註冊表)
5 action → 方法(反射)
6 存取控制檢查
7 還原 params.value
8 轉成方法宣告的參數型別 → 呼叫
9 回傳值轉成回應型別 → 放進 result
第 8 與第 9 站是昨天談過的兩個轉換器。
這條管線的設計全部落在兩個位置上。存取控制排在還原之前,第 6 站與第 7 站的相對位置是刻意的:驗證要先做,沒有通過的請求不做任何解密工作。這條順序讓一個沒有憑證的呼叫端無法用一份構造過的 payload 逼伺服器把解密與反序列化跑完。代價是存取控制那一段只能依賴 value 以外的東西,也就是 method、格式標記與憑證,看不到 value 裡面裝了什麼(這道檢查本身的語意屬於 Day 23)。
失敗落在第 2 站與第 3 站之間的哪一邊,決定它以什麼形式回報。
| 落點 | 語意 | HTTP 狀態碼 |
|---|---|---|
| 第 1、2 站 | 這個請求本身不合格 | 4xx |
| 第 3 站之後 | 請求已受理,這次呼叫失敗 | 200 |
兩邊回的都是同一種 JSON-RPC payload,差別只在狀態碼。不是 JSON、沒有 method、憑證不過,這些在請求被受理之前就退掉了,用 HTTP 狀態碼回報;受理之後的每一種呼叫失敗,包含找不到方法、權限不足、業務規則擋下來,一律 HTTP 200,錯誤裝進 error 欄位。
error 是一個三欄的物件:
| 欄位 | 型別 | 內容 |
|---|---|---|
code |
整數 | 錯誤碼 |
message |
字串 | 錯誤訊息 |
data |
任意 | 補充資訊,多數路徑不填 |
result 與 error 互斥,一次回應只會有其中一個帶值。錯誤碼一部分是 JSON-RPC 2.0 自己的標準碼,其餘取的是規格留給伺服端自訂的那一段,至於各個碼的語意、哪些訊息可以原樣送到使用者面前、client 怎麼依碼把它翻回一個例外,是 Day 18 的題目。
這條分界的實務後果落在維運那一側:監控與閘道看得到的只有第一段。一次被業務規則擋下來的存檔,在 HTTP 那一層是一筆成功的請求,要看見它得讀 body。這是選了 RPC 之後必然要接受的代價之一,跟第一節那兩筆是同一件事的不同位置。
案例把這個端點開出來的程式碼在 ApiController:
public class ApiController : ApiServiceController
{
}
整個檔案就是這幾行加上一段註解,路由、POST handler、請求解析、憑證檢查、派發全部在框架的基底類別裡。之所以還要這個空子類,是因為框架那一支是抽象類別,controller 的探索挑不到它。
這幾行換到的是每一站都可以替換:ReadRequestAsync、ValidateAuthorization、HandleRequestAsync 都是 protected virtual,而案例一個都沒有覆寫。一次 System.Ping 回得出來,第五節那九站就全部走過一遍,而為此寫的程式碼就是上面那個空類別。Day 3 說八張表單只有一張需要應用接手,那是業務邏輯那一側;傳輸這一側連那一張都不必接手。
一份協定真正在規範的不是欄位叫什麼名字,是一次呼叫的邊界:什麼算是一個請求、它在哪一刻算被受理、被受理之後的失敗要用什麼形式回報。
params 那一格裝的是一份 API payload,裡面固定是一個參數物件,因為每一支對外方法都只收一個參數error 欄位可以帶走的判準來自第三節那張對照表:一份實作偏離公開規格時,付代價的不是實作的人,是照著規格寫 client 的那個人。那四項各自都是合理的取捨,但沒有一項會在文件以外的地方被告知。偏離本身不是問題,不寫下來才是。
明天談另一側:同一份合約,桌面端、瀏覽器端與同一個行程內的呼叫,怎麼收斂成同一條路徑。
本系列同步發表於 HackMD,完整目錄